What software invariants can learn from the difference between the rules we claim and the states our systems actually permit.
There is a special kind of bug that feels almost insulting. Not the fun kind. Not the “oh wow, distributed systems are hard” kind. Not the race condition that makes you stare into the void for three hours and briefly consider opening a bakery. I mean the dumb bug.
The one where the system lets something exist that everyone on the team would confidently say is impossible.
An invoice without a customer.
A paid order with no payment.
A user marked as deleted who can still log in.
A subscription that is both cancelled and active.
You look at it and think: wait, that can’t happen. And that sentence is usually where the problem begins. Because apparently it can.
The rules we think we have
Most systems have rules. We call them different things depending on the context:
- domain rules
- business logic
- invariants
- assumptions
- things “that should never happen”
- comments above a function that everyone ignores after month three
Some rules are simple:
An invoice must have a customer.
A booking must have a start date before its end date.
A payment cannot be refunded before it has been captured.
A user cannot belong to an organization that does not exist.
These rules feel obvious. Almost boring. Which is dangerous, because boring rules are exactly the ones we stop checking carefully.
We say things like:
“An invoice always has a customer.”
But what do we actually mean?
Do we mean the database enforces it?
Do we mean the ORM model requires it?
Do we mean the API validates it?
Do we mean the frontend usually sends it?
Do we mean there is a CreateInvoiceService that checks it, unless someone imports the repository directly during a migration and now that shortcut is load-bearing archaeology?
Because those are very different statements.
There is a word from mathematical logic that captures this better than most software vocabulary does: satisfaction. I know. Model theory is not usually where you go looking for production debugging advice. Stay with me.
Satisfaction from Model Theory
In mathematical logic, a structure satisfies a theory when, under that structure’s interpretation of the theory’s language, the theory’s statements come out true. For software purposes, you can read that as: the actual system state obeys the rules your model claims.
That sounds abstract because mathematicians have a professional obligation to make useful things sound like furniture assembly manuals. But in software terms, it is beautifully simple.
In this analogy, your theory is the set of domain rules, invariants, schema constraints, and contracts you claim hold. Your structure is the concrete world those claims are about: database rows, objects in memory, events in a queue, files in a bucket, whatever your system really contains when nobody is looking.
The question is:
Does the actual state of the system satisfy the rules we say the system has?
That’s it. That’s the whole thing. And once you see it, you start noticing a lot of software design problems are really satisfaction problems wearing fake moustaches.
The invoice that proved the model was lying
Imagine your codebase has an Invoice. Everyone knows an invoice belongs to a customer. That is not controversial. The product manager knows it. The finance team knows it. The engineer who joined yesterday knows it. Even the AI assistant knows it, because it has seen the word “invoice” near the word “customer” in seventeen billion GitHub repositories.
So somewhere in the code, you have this:
@dataclass(frozen=True)
class Invoice:
id: UUID
customer_id: str
amount: Money
status: InvoiceStatus
detail: InvoiceDetailLooks fine. Then one day, production explodes because an invoice exists with customer_id = null. Everyone is offended. “How did this happen?” Well. Very carefully, probably.
Maybe the database column was nullable because the first migration was written quickly. Maybe a backfill script created partial invoices. Maybe tests used factories that defaulted everything to None. Maybe an import job bypassed the domain service. Maybe the type hint said str, but nothing was checking it at runtime, and Python, being Python, politely declined to care.
The point is not the specific failure. The point is that your actual system allowed a state that your domain language said was impossible.
Your theory said: “Every invoice has a customer.”
Your structure said: “lol.”
And the running system is the evidence. If it can produce that state as a durable or externally visible state, then the rule was not actually enforced at the boundary where you thought it was.
Your real model is what the system permits
This is the uncomfortable part. Your domain model is not what is written in the architecture document. It is not what the team says during planning. It is not what the class names suggest. It is not even what the “happy path” code does most of the time.
In the software-engineering sense, your effective model includes the states your system can actually enter. If the database allows an invoice without a customer, and some path can write one, then “invoice without customer” is part of the system’s effective state space. Maybe accidentally. Maybe shamefully. Maybe with a comment saying # temporary. But it is there.
There is one wrinkle: some systems intentionally permit temporary or intermediate states. That is fine, but then the honest invariant is not “this state never exists.” It is something like “this state is only allowed during this workflow,” or “this state must be reconciled before it becomes externally visible.”
This is why I’m increasingly suspicious of domain rules that only live in service-layer code. Not because service-layer validation is bad. It is often necessary. But if a rule really defines the shape of the domain, and the rest of the system can casually route around it, then the rule is more of a suggestion. A polite request. A Post-it note on the side of a machine with several exposed blades.
I say this as someone who has absolutely shipped “temporary” validation in a service layer and then acted surprised when the rest of the system treated it as optional. The system was not confused. I was.
Your codebase is training data now
AI coding assistants make this problem harder to ignore. AI tools are very good at reading the apparent shape of a codebase. They pick up names, patterns, conventions, file structure, nearby examples. If your code implies that an invoice can exist without a customer, the AI will probably believe you. Why wouldn’t it?
It is not sitting there with deep moral convictions about accounting. It is following the structure you gave it. If half your tests create Invoice(customer_id=None) because it was convenient, your assistant will infer that this is a normal thing to do. Or at least a precedent worth copying.
If your database schema says nullable but your domain document says required, the agent has to choose which reality to trust. And honestly, I would usually trust the database too. It is at least executable, though in distributed systems it may be only one of several places where reality is encoded.
This is one of the weird lessons of working with AI-generated code: implicit assumptions become very expensive. A human teammate might ask, “Wait, should this be nullable?” An AI assistant might simply continue the pattern at high speed, with great confidence and absolutely no sense of shame. That’s not because the AI is uniquely stupid. It is because the codebase is communicating something. The machine is just listening more literally than we are used to.
Invariants should live as low as they reasonably can
So what do we do with this? The tempting answer is: “enforce everything everywhere.” Which sounds responsible until you actually try it and end up with seven layers of duplicate validation that all disagree slightly. Congratulations, you now have a distributed bureaucracy.
I think the better heuristic is:
Put invariants as close as possible to the place where invalid state would otherwise be born.
There is no holy layer. There is only the layer where the lie becomes impossible.
Sometimes that is the type system. If a value cannot be negative, maybe it should not be represented as a naked integer floating around the system with good vibes attached.
Types help most where all construction paths go through them. They help less at serialization, database, migration, or untyped-language boundaries unless those boundaries validate too.
Sometimes it is the database. If every invoice must have a customer, maybe customer_id should be NOT NULL and backed by a foreign key, not just treated as required by convention. Revolutionary stuff.
Sometimes it is a state machine. If a payment cannot move from created to refunded without being captured, model and enforce the transitions explicitly instead of letting callers update a status field directly.
That one is worth calling out: not every rule is about one frozen snapshot of state. Some rules are about allowed transitions over time. In logic terms, that kind of rule is less like a simple snapshot predicate and more like a property of traces: which histories are allowed, not just which records look valid right now.
A payment lifecycle is not just a set of fields; it is a little story with legal and illegal next chapters.
Sometimes it is a domain constructor. Do not let invalid objects exist and then rely on every later function to handle them carefully. That is how you turn your codebase into a haunted house.
The point is not to be dogmatic. The point is to be honest about where the rule is actually enforced. If a rule matters, make the relevant part of the system satisfy it structurally, at the right enforcement boundary. If it only lives in someone’s head, it is not an invariant. It is folklore.
But not every rule deserves concrete
There is a trap here. Once you start thinking this way, you may want to encode every business rule into the deepest possible layer of the system. Types everywhere. Constraints everywhere. State machines inside state machines. A cathedral of correctness.
I have sympathy for this. I also have production experience, which is the medical condition that prevents you from fully enjoying beautiful ideas.
Some rules are stable. Some are not. Some rules belong to the domain. Others are current policy. That difference matters. “An invoice must have a customer” is probably stable.
“Customers from this region get a 7% discount unless they are enterprise customers created before March” is probably not something you want carved into your database schema by a person having a particularly principled morning.
A good model does not freeze every rule. It distinguishes between rules that define the domain and rules that describe current behavior. This is where design gets annoying, because the answer is not “always enforce invariants in the database” or “always keep business rules in services.”
The answer is: think. Terrible, I know.
A useful distinction: some rules constrain valid states, some constrain legal transitions, and some are merely current policy. They should not all be enforced in the same place.
The useful question
The concept of satisfaction gives us a very practical question:
What states does our system allow that our domain language says should be impossible?
The less polite version:
What can our system do that we would be embarrassed to admit is valid?
That question is uncomfortable in exactly the right way.
Ask it about your database. Ask it about your API. Ask it about your test factories. Ask it about your event streams. Ask it about your admin tools, because admin tools are where invariants go to die wearing a friendly UI.
And maybe now, ask it about the examples you feed your AI assistant. Because those examples are not neutral. They teach the assistant what your system believes. If your codebase is full of invalid-but-convenient objects, the AI will not interpret them as a moral failure. It will interpret them as training data.
Research note
This essay borrows the idea of satisfaction from model theory, a branch of mathematical logic that studies the relationship between formal languages and the structures that make their statements true.
In software terms:
- The “theory” is analogous to your set of domain rules, invariants, schema constraints, contracts, and type-level claims.
- The “structure” is the concrete interpretation those claims are about: runtime objects, database rows, events, files, external references, and so on.
- Satisfaction asks whether the claims are true of that structure. In software terms: does the actual system state obey the rules the design says it has?
Strictly speaking, classical model theory is about truth of formal sentences in structures. Software systems also involve reachability, mutation, traces, and temporal properties, so the analogy is most useful as a design heuristic, not as a literal formalization.
The useful translation for software design:
Prefer important invariants to be structural, not merely procedural.
Or less politely:
If the rule matters, don’t leave it as a wish.
Closing thought
The model your system actually enforces is not the story you tell about it. It is the space of states the system can actually enter. That is a slightly unsettling thought, but I think it is a useful one. It shifts the conversation from “we know this can’t happen” to “where exactly is that made impossible?” And that is usually where better design starts.
Not with a grand architecture diagram. Not with a new framework. Just with the small, annoying, extremely productive realization that if your system can enter an invalid state, then your model includes that state. Whether you meant it or not.